fix: add shell:true for Windows .cmd file spawn#9
Conversation
On Windows, spawn() with a .cmd file path (e.g. claude.cmd) throws EINVAL without shell:true. This causes the plugin to silently fail — the CLI process never starts, and OpenCode receives an empty response. Uses process.platform === "win32" to only enable shell mode on Windows, avoiding unnecessary shell overhead on Unix. Fixes both spawn sites: session-manager (persistent sessions) and claude-code-language-model (doGenerate one-shot calls).
- Add mcpConfigPath to config/settings types, pass --mcp-config flag - Filter empty text blocks in message-builder to prevent API errors - Update LanguageModelV2Usage to new nested format (inputTokens.total/cacheRead) - Replace empty user message with "Continue." to avoid cache_control errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR primarily targets Windows compatibility for launching the Claude CLI by adjusting child_process.spawn() options, so .cmd shims can be executed successfully on win32. It also includes several additional functional changes (new config option, message-building behavior, and usage-shape changes) that expand the scope beyond the PR description.
Changes:
- Enable
shell: process.platform === "win32"for Claude CLI subprocess spawning in both persistent-session and one-shot execution paths. - Add an optional
mcpConfigPathconfiguration value and plumb it through to CLI args (--mcp-config). - Modify prompt/message construction to drop empty text parts and send a synthetic
"Continue."message when no content is produced; adjustusageobject shaping/casting.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types.ts | Adds mcpConfigPath to public config/settings types. |
| src/session-manager.ts | Uses shell on Windows for spawn; adds --mcp-config arg support. |
| src/message-builder.ts | Skips empty text parts; changes fallback message to "Continue.". |
| src/index.ts | Passes mcpConfigPath from provider settings into model config. |
| src/claude-code-language-model.ts | Uses shell on Windows for spawn; passes mcpConfigPath; changes usage shaping and adds type casts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const proc = spawn(cliPath, cliArgs, { | ||
| cwd, | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| shell: process.platform === "win32", | ||
| env: { ...process.env, TERM: "xterm-256color" }, |
There was a problem hiding this comment.
On Windows, enabling shell: true changes argument handling and can introduce command-injection/quoting issues if any CLI args (e.g., --model, paths) contain shell metacharacters or spaces. Consider avoiding shell unless the target is actually a .cmd shim (e.g., detect/resolve the real executable), or use a Windows-safe spawn helper that properly escapes arguments when invoking cmd.exe.
| const proc = spawn(this.config.cliPath, cliArgs, { | ||
| cwd, | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| shell: process.platform === "win32", | ||
| env: { ...process.env, TERM: "xterm-256color" }, | ||
| }) | ||
|
|
There was a problem hiding this comment.
Same concern as the session manager: shell: process.platform === "win32" can change quoting semantics and allow shell interpretation of arguments. Prefer a .cmd-specific execution path (or an escaping helper) so only the Windows shim case uses a shell and arguments remain safely escaped.
| const proc = spawn(this.config.cliPath, cliArgs, { | |
| cwd, | |
| stdio: ["pipe", "pipe", "pipe"], | |
| shell: process.platform === "win32", | |
| env: { ...process.env, TERM: "xterm-256color" }, | |
| }) | |
| const isWindowsCmdShim = | |
| process.platform === "win32" && | |
| /\.(cmd|bat)$/i.test(this.config.cliPath) | |
| const escapeForCmdExe = (value: string): string => { | |
| const escapedForQuotes = value.replace(/"/g, '""') | |
| const escapedMetaChars = escapedForQuotes.replace( | |
| /([()%!^"<>&|])/g, | |
| "^$1", | |
| ) | |
| return `"${escapedMetaChars}"` | |
| } | |
| const proc = isWindowsCmdShim | |
| ? spawn(process.env.ComSpec ?? "cmd.exe", [ | |
| "/d", | |
| "/s", | |
| "/c", | |
| `${escapeForCmdExe(this.config.cliPath)} ${cliArgs | |
| .map((arg) => escapeForCmdExe(arg)) | |
| .join(" ")}`, | |
| ], { | |
| cwd, | |
| stdio: ["pipe", "pipe", "pipe"], | |
| shell: false, | |
| env: { ...process.env, TERM: "xterm-256color" }, | |
| }) | |
| : spawn(this.config.cliPath, cliArgs, { | |
| cwd, | |
| stdio: ["pipe", "pipe", "pipe"], | |
| shell: false, | |
| env: { ...process.env, TERM: "xterm-256color" }, | |
| }) |
| export interface ClaudeCodeConfig { | ||
| provider: string | ||
| cliPath: string | ||
| cwd?: string | ||
| skipPermissions?: boolean | ||
| mcpConfigPath?: string | ||
| } | ||
|
|
||
| export interface ClaudeCodeProviderSettings { | ||
| cliPath?: string | ||
| cwd?: string | ||
| name?: string | ||
| skipPermissions?: boolean | ||
| mcpConfigPath?: string | ||
| } |
There was a problem hiding this comment.
This PR introduces a new public config surface (mcpConfigPath) that isn’t mentioned in the PR description and isn’t documented in the README configuration section. Either update the PR description/docs accordingly, or split these changes into a separate PR to keep the Windows spawn fix scoped and reviewable.
| if (content.length === 0) { | ||
| return JSON.stringify({ | ||
| type: "user", | ||
| message: { | ||
| role: "user", | ||
| content: [{ type: "text", text: "" }], | ||
| content: [{ type: "text", text: "Continue." }], | ||
| }, | ||
| }) | ||
| } |
There was a problem hiding this comment.
The new behavior here (dropping empty text parts and emitting a synthetic "Continue." message) is a functional change unrelated to the Windows .cmd spawning fix described in the PR. Please either justify it in the PR description (including the motivating bug) and add coverage, or move it into a separate PR to avoid scope creep.
| inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined }, | ||
| outputTokens: { total: 0, text: undefined, reasoning: undefined }, | ||
| } as unknown as LanguageModelV2Usage, |
There was a problem hiding this comment.
The usage shape is being changed and then forced through with as unknown as LanguageModelV2Usage, which bypasses type safety and may break downstream consumers expecting the previous runtime shape. Prefer constructing a LanguageModelV2Usage value that type-checks without double-casting (or keep the prior shape) and ensure the change is intentional/documented since it’s outside the PR’s stated scope.
| inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined }, | |
| outputTokens: { total: 0, text: undefined, reasoning: undefined }, | |
| } as unknown as LanguageModelV2Usage, | |
| inputTokens: 0, | |
| outputTokens: 0, | |
| totalTokens: 0, | |
| }, |
Problem
On Windows, the Claude CLI is installed as
claude.cmd(a batch file wrapper). Node.js'schild_process.spawn()requiresshell: trueto execute.cmdfiles — without it, spawn throwsEINVALsilently.The plugin catches this error in the session manager but only logs it at debug level (which is disabled by default). The result: the CLI process never starts, OpenCode receives an empty response, and Claude Code appears to "do nothing."
Fix
Add
shell: process.platform === "win32"to both spawn() call sites:session-manager.ts(persistent session spawning)claude-code-language-model.ts(one-shotdoGeneratecalls)The condition is platform-gated so Unix systems are unaffected.
Testing
Verified on Windows 11 (Node.js v24.13.0):
spawn EINVAL→ empty response → "Opus does nothing"